﻿using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
using System.Web.UI;

namespace MvcSupplantCaching
{
    /// <summary>
    /// Replaces the <seealso cref="OutputCacheAttribute"/> for the purpose of enabling post cache substitution.
    /// </summary>
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
    public class SupplantOutputCacheAttribute : ActionFilterAttribute, IExceptionFilter, ISupplantOutputCacheAttribute
    {
        // Protected
        protected readonly ICacheHeadersHelper CacheHeadersHelper;
        protected readonly ICacheSettingsManager CacheSettingsManager;
        protected readonly IKeyGenerator KeyGenerator;
        protected readonly IReadWriteOutputCacheManager OutputCacheManager;

        private CacheSettings _cacheSettings;

        /// <summary>
        /// The CacheSettings for the attribute.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.Web.HttpException">
        /// The 'duration' attribute must have a value that is greater than or equal to zero.
        /// </exception>
        public CacheSettings CacheSettings
        {
            get
            {
                if (_cacheSettings == null)
                {
                    _cacheSettings = BuildCacheSettings();
                }
                return _cacheSettings;
            }
        }

        /// <summary>
        /// The header key that will be attached to the response for output cached pages.
        /// </summary>
        public const string OutputCacheHeaderKey = "X-Mvc-Supplant-OutputCache";

        /// <summary>
        /// The header key that will be attached to the response for pages that are output cachable.
        /// </summary>
        public const string CacheableHeaderKey = "X-Mvc-Supplant-Cachable";

        /// <summary>
        /// The supplant format token. These are inserted into the HTML document to tag where supplants should occur.
        /// </summary>
        public const string SupplantTokenFormat = "<!-- s-{0} -->";

        // Private
        private bool? _noStore;
        private OutputCacheOptions? _options;

        /// <summary>
        /// The default constructor. Instantiates the <see cref="KeyGenerator"/> using a new <see cref="KeyBuilder"/>.
        /// </summary>
        public SupplantOutputCacheAttribute() : this(new KeyBuilder()) { }

        /// <summary>
        /// Constructor allowing the specification of a <see cref="KeyBuilder"/> to use.
        /// </summary>
        /// <param name="keyBuilder">The <see cref="IKeyBuilder"/> that will be used to generate the cache keys.</param>
        public SupplantOutputCacheAttribute(IKeyBuilder keyBuilder) :
            this(
               new KeyGenerator(keyBuilder),
               MvcSupplantCaching.OutputCacheManager.GetInstance(OutputCache.Instance, keyBuilder),
               new CacheSettingsManager(),
               new CacheHeadersHelper()
        )
        { }

        /// <summary>
        /// Fully parameterized constructor.
        /// </summary>
        /// <param name="keyGenerator">The <see cref="KeyGenerator"/> to be used for making the cache keys for the output cache.</param>
        /// <param name="outputCacheManager">The <see cref="IReadWriteOutputCacheManager"/> that will be used to get and set data from the output cache.</param>
        /// <param name="cacheSettingsManager">The <see cref="ICacheSettingsManager"/> that will be used to manage the output cache's settings.</param>
        /// <param name="cacheHeadersHelper">The <see cref="ICacheHeadersHelper"/> that will be used to manage the cache headers for the response.</param>
        protected SupplantOutputCacheAttribute(
            IKeyGenerator keyGenerator, IReadWriteOutputCacheManager outputCacheManager,
            ICacheSettingsManager cacheSettingsManager, ICacheHeadersHelper cacheHeadersHelper
        )
        {
            KeyGenerator = keyGenerator;
            OutputCacheManager = outputCacheManager;
            CacheSettingsManager = cacheSettingsManager;
            CacheHeadersHelper = cacheHeadersHelper;

            Duration = -1;
            Location = (OutputCacheLocation)(-1);
            Options = OutputCache.DefaultOptions;
        }

        /// <summary>
        /// Gets or sets the cache duration, in seconds.
        /// </summary>
        public int Duration
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the vary-by-param value.
        /// </summary>
        public string VaryByParam
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the vary-by-custom value.
        /// </summary>
        public string VaryByCustom
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the cache profile name.
        /// </summary>
        public string CacheProfile
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets the location.
        /// </summary>
        public OutputCacheLocation Location
        {
            get;
            set;
        }

        /// <summary>
        /// Gets or sets a value that indicates whether to store the cache.
        /// </summary>
        public bool NoStore
        {
            get
            {
                return _noStore ?? false;
            }
            set
            {
                _noStore = value;
            }
        }

        /// <summary>
        /// Get or sets the <see cref="OutputCacheOptions"/> for this attributes. Specifying a value here will
        /// make the <see cref="OutputCache.DefaultOptions"/> value ignored.
        /// </summary>
        public OutputCacheOptions Options
        {
            get
            {
                return _options ?? OutputCacheOptions.None;
            }
            set
            {
                _options = value;
            }
        }

        /// <summary>
        /// Exception handler.
        /// </summary>
        /// <param name="filterContext">The current exception context.</param>
        public void OnException(ExceptionContext filterContext)
        {
            if (CacheSettings != null)
            {
                ExecuteCallback(filterContext, true);
            }
        }

        /// <summary>
        /// Called before an action method executes.
        /// </summary>
        /// <param name="filterContext">The filter context.</param>
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // Mark this response as cacheable.
            filterContext.HttpContext.Response.AddHeader(CacheableHeaderKey, "true");

            var cacheKey = KeyGenerator.GenerateKey(filterContext, CacheSettings);

            // If we are unable to generate a cache key it means we can't do anything
            if (string.IsNullOrEmpty(cacheKey))
            {
                return;
            }

            // Are we actually storing data on the server side ?
            if (CacheSettings.IsServerCachingEnabled)
            {
                // Add the current URL to the cache.
                OutputCacheManager.RequestCache[OutputCache.UrlCacheKey] = filterContext.HttpContext.Request.RawUrl;

                // Add the key to the httpcontext items.
                OutputCacheManager.RequestCache[OutputCache.CacheKey] = cacheKey;

                CacheItem cachedItem = null;

                // If the request is a POST, we lookup for NoCacheLookupForPosts option
                // We are fetching the stored value only if the option has not been set and the request is not a POST
                if (
                    (CacheSettings.Options & OutputCacheOptions.NoCacheLookupForPosts) != OutputCacheOptions.NoCacheLookupForPosts ||
                    filterContext.HttpContext.Request.HttpMethod != "POST"
                )
                {
                    cachedItem = OutputCacheManager.GetItem(cacheKey);
                }

                // We have a cached version on the server side
                if (cachedItem != null)
                {
                    // This is an output cached page.
                    filterContext.HttpContext.Response.AddHeader(OutputCacheHeaderKey, "true");

                    // We inject the previous result into the MVC pipeline
                    // The MVC action won't execute as we injected the previous cached result.
                    string content = (string)cachedItem.Content.Clone();

                    // Loop over the functions that have been cached for this page, and replace their tokens in the response.
                    var cacheContainer = OutputCache.SupplantProvider[cacheKey] as SupplantCacheItemContainer;
                    if (cacheContainer != null)
                    {
                        var sb = new StringBuilder(content);
                        int adjustment = 0;
                        foreach (var cacheObj in cacheContainer.CacheItems)
                        {
                            ReplaceTokenContent(cacheObj, sb, cacheObj.StartIndex, ref adjustment);
                        }
                        content = sb.ToString();
                        sb = null;
                    }
                    
                    // Set the response based on the cached content that may have had supplants on it.
                    filterContext.Result = new ContentResult
                    {
                        Content = content,
                        ContentType = cachedItem.ContentType
                    };
                }
            }

            // Did we already injected something ?
            if (filterContext.Result != null)
            {
                return; // No need to continue 
            }

            // We are hooking into the pipeline to replace the response Output writer
            // by something we own and later eventually gonna cache
            var cachingWriter = new StringWriter(CultureInfo.InvariantCulture);

            var originalWriter = filterContext.HttpContext.Response.Output;

            filterContext.HttpContext.Response.Output = cachingWriter;

            // Will be called back by OnResultExecuted -> ExecuteCallback
            filterContext.HttpContext.Items[cacheKey] = new Action<bool>(hasErrors =>
            {
                // Removing this executing action from the context
                filterContext.HttpContext.Items.Remove(cacheKey);

                // We restore the original writer for response
                filterContext.HttpContext.Response.Output = originalWriter;

                if (hasErrors)
                {
                    return; // Something went wrong, we are not going to cache something bad
                }

                string content = cachingWriter.ToString();

                // Now we use owned caching writer to actually store data
                var cacheItem = new CacheItem
                {
                    Content = content,
                    ContentType = filterContext.HttpContext.Response.ContentType
                };

                if (CacheSettings.IsServerCachingEnabled && filterContext.HttpContext.Response.StatusCode == 200)
                {
                    OutputCacheManager.AddItem(cacheKey, cacheItem, DateTime.UtcNow.AddSeconds(CacheSettings.Duration));

                    // aggregate the current page supplant cache into a single supplant cache entry
                    var supplantCache = OutputCache.SupplantProvider.AggregateCache(cacheKey,
                        DateTime.UtcNow.AddSeconds(CacheSettings.Duration), content).CacheItems;

                    // Loop over the items in the supplant cache and modify the response.
                    var sb = new StringBuilder(content);
                    int adjustment = 0;
                    foreach (var kvp in supplantCache)
                    {
                        ReplaceTokenContent(kvp, sb, kvp.StartIndex, ref adjustment);
                    }
                    content = sb.ToString();
                    sb = null;
                }
                
                filterContext.HttpContext.Response.Write(content);
            });
        }

        /// <summary>
        /// Replaces the supplant tokens with the supplanted content.
        /// </summary>
        /// <param name="cacheObj">The <see cref="SupplantCacheItem"/> that needs to be supplanted into the content.</param>
        /// <param name="sb">The string builder for the to use for content replacements.</param>
        /// <param name="adjustedStartIndex">The start index of the token in the markup after previous tokens, if any, have been replaced.</param>
        private static void ReplaceTokenContent(SupplantCacheItem cacheObj, StringBuilder sb, int startIndex, ref int adjustment)
        {
            string replaceToken = string.Format(SupplantTokenFormat, cacheObj.Key);
            string replacement = string.Empty;

            if (cacheObj.Function as Func<string> != null || cacheObj.Function as Func<HtmlString> != null)
            {
                Func<string> supplantFunc = cacheObj.Function as Func<string>;
                if (supplantFunc != null)
                {
                    replacement += supplantFunc();
                }

                Func<IHtmlString> htmlStringFunc = cacheObj.Function as Func<IHtmlString>;
                if (htmlStringFunc != null)
                {
                    replacement += htmlStringFunc();
                }

                int adjustedStartIndex = startIndex + adjustment;

                if (adjustedStartIndex >= 0)
                {
                    sb.Remove(adjustedStartIndex, replaceToken.Length);
                    sb.Insert(adjustedStartIndex, replacement);
                    adjustment += -(replaceToken.Length - replacement.Length);
                }
            }
            Action supplantAction = cacheObj.Function as Action;
            if (supplantAction != null)
            {
                supplantAction();
            }
        }

        /// <summary>
        /// Called after an action result executes.
        /// </summary>
        /// <param name="filterContext">The filter context.</param>
        public override void OnResultExecuted(ResultExecutedContext filterContext)
        {
            if (CacheSettings == null)
            {
                return;
            }

            // See OnActionExecuting
            ExecuteCallback(filterContext, filterContext.Exception != null);

            // If we are in the context of a child action, the main action is responsible for setting
            // the right HTTP Cache headers for the final response.
            if (!filterContext.IsChildAction)
            {
                CacheHeadersHelper.SetCacheHeaders(filterContext.HttpContext.Response, CacheSettings);
            }

            OutputCacheManager.FinalizeDependencies();
        }

        /// <summary>
        /// Builds the cache settings.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.Web.HttpException">
        /// The 'duration' attribute must have a value that is greater than or equal to zero.
        /// </exception>
        private CacheSettings BuildCacheSettings()
        {
            CacheSettings cacheSettings;

            if (string.IsNullOrEmpty(CacheProfile))
            {
                cacheSettings = new CacheSettings
                {
                    IsCachingEnabled = CacheSettingsManager.IsCachingEnabledGlobally,
                    Duration = Duration,
                    VaryByCustom = VaryByCustom,
                    VaryByParam = VaryByParam,
                    Location = (int)Location == -1 ? OutputCacheLocation.Server : Location,
                    NoStore = NoStore,
                    Options = Options,
                };
            }
            else
            {
                var cacheProfile = CacheSettingsManager.RetrieveOutputCacheProfile(CacheProfile);

                cacheSettings = new CacheSettings
                {
                    IsCachingEnabled = CacheSettingsManager.IsCachingEnabledGlobally && cacheProfile.Enabled,
                    Duration = Duration == -1 ? cacheProfile.Duration : Duration,
                    VaryByCustom = VaryByCustom ?? cacheProfile.VaryByCustom,
                    VaryByParam = VaryByParam ?? cacheProfile.VaryByParam,
                    Location = (int)Location == -1 ? ((int)cacheProfile.Location == -1 ? OutputCacheLocation.Server : cacheProfile.Location) : Location,
                    NoStore = _noStore.HasValue ? _noStore.Value : cacheProfile.NoStore,
                    Options = Options,
                };
            }

            if (cacheSettings.Duration == -1)
            {
                throw new HttpException("The directive or the configuration settings profile must specify the 'duration' attribute.");
            }

            if (cacheSettings.Duration < 0)
            {
                throw new HttpException("The 'duration' attribute must have a value that is greater than or equal to zero.");
            }

            return cacheSettings;
        }

        /// <summary>
        /// Executes the callback.
        /// </summary>
        /// <param name="context">The context.</param>
        /// <param name="hasErrors">if set to <c>true</c> [has errors].</param>
        private void ExecuteCallback(ControllerContext context, bool hasErrors)
        {
            var cacheKey = KeyGenerator.GenerateKey(context, CacheSettings);

            if (string.IsNullOrEmpty(cacheKey))
            {
                return;
            }

            var callback = context.HttpContext.Items[cacheKey] as Action<bool>;

            if (callback != null)
            {
                callback.Invoke(hasErrors);
            }
        }

        /// <summary>
        /// Gets the cache key that represents the provided <see cref="ControllerContext"/>.
        /// </summary>
        /// <param name="controllerContext">The context to get the cache key for.</param>
        /// <returns>A cache key.</returns>
        public string GetCacheKeyForController(ControllerContext controllerContext)
        {
            return new KeyGenerator(new KeyBuilder()).GenerateKey(controllerContext, BuildCacheSettings());
        }
    }
}
